Middleware Events
Emit events and build interactive middleware with request/response patterns.
Overview
Middleware can communicate via events:
- One-way events - Fire-and-forget notifications
- Request/response - Interactive workflows (permissions, approvals)
- Typed events - Compile-time safety
- Async responses - Wait for user input mid-execution
Quick Example
public class PermissionMiddleware : IAgentMiddleware
{
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
// Emit permission request
var request = new PermissionRequestEvent
{
FunctionName = context.Function.Name,
Arguments = context.Arguments,
RequestId = Guid.NewGuid().ToString()
};
context.Emit(request);
// Wait for user response
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
request.RequestId,
ct
);
if (!response.Approved)
{
context.BlockExecution = true;
context.OverrideResult = "User denied permission";
}
}
}One-Way Events
Emitting Events
public Task BeforeIterationAsync(BeforeIterationContext context, CancellationToken ct)
{
// Emit text for UI
context.Emit(new TextDeltaEvent
{
Text = "🔍 Analyzing query..."
});
// Emit custom telemetry
context.Emit(new CustomTelemetryEvent
{
MetricName = "iteration_started",
Value = context.Iteration
});
return Task.CompletedTask;
}Fire-and-Forget Semantics
context.Emit() is fire-and-forget — it enqueues the event and returns immediately. There is no delivery guarantee: if no consumer is listening (e.g. no await foreach on the event stream), the event is silently dropped.
Use context.TryEmit() if the EventCoordinator may not be configured — it swallows the InvalidOperationException that Emit() throws in that case:
// Safe to call even if EventCoordinator is not configured
context.TryEmit(new TextDeltaEvent { Text = "Processing..." });Use context.Emit() (not TryEmit) when you know the agent has a consumer — it will throw if misconfigured, which helps catch setup errors early.
Built-in Event Types
// Text output
context.Emit(new TextDeltaEvent { Text = "Processing..." });
// Tool call started
context.Emit(new ToolCallStartedEvent
{
ToolName = "SearchWeb",
CallId = callId
});
// Tool call completed
context.Emit(new ToolCallCompletedEvent
{
ToolName = "SearchWeb",
CallId = callId,
Result = result
});
// Custom events
context.Emit(new MyCustomEvent { /* ... */ });Request/Response Pattern
Step 1: Define Events
// Request event
public class PermissionRequestEvent : AgentEvent
{
public required string FunctionName { get; init; }
public required IReadOnlyDictionary<string, object?> Arguments { get; init; }
public required string RequestId { get; init; }
}
// Response event
public class PermissionResponseEvent : AgentEvent
{
public required string RequestId { get; init; }
public required bool Approved { get; init; }
public string? DenialReason { get; init; }
}Step 2: Emit Request and Wait
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
// Emit request
var request = new PermissionRequestEvent
{
FunctionName = context.Function.Name,
Arguments = context.Arguments,
RequestId = requestId
};
context.Emit(request);
// Wait for response (blocks middleware until user responds)
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
requestId,
ct
);
// Act on response
if (!response.Approved)
{
context.BlockExecution = true;
context.OverrideResult = response.DenialReason ?? "Permission denied";
}
}Step 3: User Responds
In your UI/host code:
await foreach (var evt in agent.RunAsync("Search for flights", ct))
{
if (evt is PermissionRequestEvent permReq)
{
// Show dialog to user
var approved = await ShowPermissionDialog(permReq.FunctionName);
// Send response back to middleware
agent.SendMiddlewareResponse(permReq.RequestId, new PermissionResponseEvent
{
RequestId = permReq.RequestId,
Approved = approved,
DenialReason = approved ? null : "User declined"
});
}
}Timeout Handling
Always use CancellationToken with timeouts:
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
context.Emit(new PermissionRequestEvent
{
FunctionName = context.Function.Name,
RequestId = requestId
});
try
{
// Wait up to 30 seconds
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
requestId,
cts.Token
);
if (!response.Approved)
{
context.BlockExecution = true;
}
}
catch (OperationCanceledException)
{
// Timeout - deny by default
context.BlockExecution = true;
context.OverrideResult = "Permission request timed out";
}
}Human-in-the-Loop Pattern
Complete example with UI integration:
Middleware:
public class HumanApprovalMiddleware : IAgentMiddleware
{
public async Task BeforeParallelBatchAsync(
BeforeParallelBatchContext context,
CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString();
// Request batch approval
context.Emit(new BatchApprovalRequestEvent
{
Functions = context.ParallelFunctions
.Select(f => f.Name ?? "_unknown")
.ToList(),
RequestId = requestId
});
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(60));
try
{
var response = await context.WaitForResponseAsync<BatchApprovalResponseEvent>(
requestId,
cts.Token
);
// Store approvals in state for BeforeFunctionAsync to check
context.UpdateState(s => s with
{
MiddlewareState = s.MiddlewareState.WithBatchApprovals(
response.ApprovedFunctions.ToHashSet()
)
});
}
catch (OperationCanceledException)
{
// Timeout - deny all
context.UpdateState(s => s with
{
MiddlewareState = s.MiddlewareState.WithBatchApprovals(new HashSet<string>())
});
}
}
public Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
var isApproved = context.Analyze(s =>
s.MiddlewareState.BatchApprovals?.Contains(context.Function.Name) ?? false
);
if (!isApproved)
{
context.BlockExecution = true;
context.OverrideResult = "User did not approve this function";
}
return Task.CompletedTask;
}
}UI Code:
var agent = await new AgentBuilder()
.WithMiddleware(new HumanApprovalMiddleware())
.BuildAsync();
await foreach (var evt in agent.RunAsync("Book a flight to NYC", ct))
{
switch (evt)
{
case BatchApprovalRequestEvent req:
// Show dialog
var approved = await ShowBatchApprovalDialog(req.Functions);
// Respond
agent.SendMiddlewareResponse(req.RequestId, new BatchApprovalResponseEvent
{
RequestId = req.RequestId,
ApprovedFunctions = approved
});
break;
case TextDeltaEvent text:
Console.Write(text.Text);
break;
}
}Progress Updates
Show progress during long operations. Use BeforeIterationAsync (which has a context with Emit) for progress notifications:
public Task BeforeIterationAsync(BeforeIterationContext context, CancellationToken ct)
{
context.Emit(new TextDeltaEvent { Text = "🤔 Thinking..." });
return Task.CompletedTask;
}Custom Event Types
Define your own events:
public class TokenUsageEvent : AgentEvent
{
public required int PromptTokens { get; init; }
public required int CompletionTokens { get; init; }
public required decimal Cost { get; init; }
}
public class AuditLogEvent : AgentEvent
{
public required string Action { get; init; }
public required string UserId { get; init; }
public required DateTime Timestamp { get; init; }
}Usage:
// WrapModelCallStreamingAsync receives ModelRequest (not context), so use AfterMessageTurnAsync
// for emitting telemetry after the full turn completes.
public Task AfterMessageTurnAsync(AfterMessageTurnContext context, CancellationToken ct)
{
// Emit token usage (summed across all iterations)
if (context.TurnUsage != null)
{
context.Emit(new TokenUsageEvent
{
PromptTokens = (int)(context.TurnUsage.InputTokenCount ?? 0),
CompletionTokens = (int)(context.TurnUsage.OutputTokenCount ?? 0),
Cost = CalculateCost(context.TurnUsage)
});
}
// Emit audit log
context.Emit(new AuditLogEvent
{
Action = "llm_call",
UserId = context.AgentName,
Timestamp = DateTime.UtcNow
});
return Task.CompletedTask;
}Multi-Step Workflows
Chain multiple request/response interactions:
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
// Step 1: Request permission
var permRequestId = Guid.NewGuid().ToString();
context.Emit(new PermissionRequestEvent
{
FunctionName = context.Function.Name,
RequestId = permRequestId
});
var permResponse = await context.WaitForResponseAsync<PermissionResponseEvent>(
permRequestId,
ct
);
if (!permResponse.Approved)
{
context.BlockExecution = true;
return;
}
// Step 2: Request additional parameters (if needed)
if (NeedsMoreInfo(context.Arguments))
{
var paramRequestId = Guid.NewGuid().ToString();
context.Emit(new ParameterRequestEvent
{
FunctionName = context.Function.Name,
MissingParameters = GetMissingParams(context.Arguments),
RequestId = paramRequestId
});
var paramResponse = await context.WaitForResponseAsync<ParameterResponseEvent>(
paramRequestId,
ct
);
// Merge parameters
MergeParameters(context.Arguments, paramResponse.Parameters);
}
// Function now has permission and all required parameters
}Event Filtering
Filter events in your UI:
await foreach (var evt in agent.RunAsync("Search flights", ct))
{
switch (evt)
{
// Only handle specific event types
case TextDeltaEvent text:
Console.Write(text.Text);
break;
case PermissionRequestEvent req:
await HandlePermissionRequest(req);
break;
case TokenUsageEvent usage:
UpdateCostDisplay(usage.Cost);
break;
// Ignore all other events
default:
break;
}
}Best Practices
1. Always Use Request IDs
// GOOD: Unique request ID
var requestId = Guid.NewGuid().ToString();
context.Emit(new MyRequestEvent { RequestId = requestId });
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);
// BAD: Hardcoded or predictable ID
context.Emit(new MyRequestEvent { RequestId = "request1" });2. Set Timeouts
// GOOD: Timeout to prevent hanging
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, cts.Token);
}
catch (OperationCanceledException)
{
// Handle timeout
}
// BAD: No timeout
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);3. Handle Cancellation
// GOOD: Graceful cancellation handling
try
{
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);
// Process response
}
catch (OperationCanceledException)
{
context.BlockExecution = true;
context.OverrideResult = "Operation cancelled";
}
// BAD: Let exception propagate
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);4. Validate Business Logic (Not Framework Routing)
WaitForResponseAsync<T>(requestId, ct) already enforces the request ID match internally — you don't need to re-check it. Validate your own business logic instead:
// GOOD: Validate business logic
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(requestId, ct);
if (!response.Approved)
{
context.BlockExecution = true;
context.OverrideResult = response.DenialReason ?? "Permission denied";
}
// BAD: Redundant framework-level checks
if (response.RequestId != requestId) // Framework already guarantees this
throw new InvalidOperationException("Response ID mismatch");Next Steps
- 04.1 Middleware Lifecycle - All middleware hooks
- 04.2 Middleware State - Store approvals in state
- 04.4 Built-in Middleware - See event usage in practice
- 04.5 Custom Middleware - Build interactive middleware